﻿\subsection{系统调用与 C 标准库}

操作系统除了具备资源管理和调度能力，还应向运行于操作系统上的用户程序提供一组易用的接口，即 \textbf{系统调用（System Call）}。
运行于用户态下的线程不具备直接操作除通用寄存器、段寄存器和分配给进程使用的内存资源以外的其他计算机硬件的权限。
用户想要完成非计算型任务必须由操作系统代为完成，即线程必须暂停用户程序的执行，陷入内核态转而执行操作系统内核程序。
线程从用户态迁移到内核态，执行操作系统内核程序的过程就是发起系统调用的过程。
在 x86 体系结构中，发起系统调用的手段有多种，如中断门、陷阱门、调用门、32 位快速系统调用指令 \lstinline{sysenter} 和 \lstinline{sysexit}、
64 位快速系统调用指令 \lstinline{syscall} 和 \lstinline{sysret}。
其中兼容性最好的就是通过中断门、陷阱门、调用门发起软中断实现的系统调用，
依赖于具体 CPU 模组的专门的系统调用指令则具有更好的执行效率。
在 16 位实模式下编写 MBR 即引导加载器时，就曾经大量地发起软中断使用 BIOS 提供的功能函数。
进入保护模式后，通过在 IDT 中设置正确的中断门描述符，就可以使当前执行流中断，进而陷入内核态执行内核代码，从而实现系统调用。
例如，Windows 操作系统下的 32 位程序可使用 \lstinline{int 0x2E} 软中断发起系统调用，GNU/Linux 下的 32 位程序则可使用 \lstinline{int 0x80} 软中断发起系统调用。

系统调用的实现依赖于具体的操作系统平台，且系统调用在使用上不够方便。
为此，操作系统在提供系统调用的同时，还会将系统调用封装为高级语言库函数，C 语言语法简单且易于扩展，因此各大操作系统平台基于 C 语言提供了丰富的 C 语言库。
如 Windows 下的 MSVCRT（Microsoft C Runtime）和 GNU/Linux 下的 glibc （GNU C Library）。
将系统调用封装为符合标准库规范的 C 语言库后，就可以在不同体系结构的操作系统间实现程序源代码的移植。

本节中将以 SimpleOS \lstinline{WriteConsole} 为例讨论系统调用的实现，并将其封装为 C 语言标准库函数 \lstinline{printf}。

\subsubsection{系统调用}

在第四章中，已经探讨过操作操作内核字符串打印的实现。用户程序要想使用该功能，需要主动陷入内核态。
效仿 GNU/Linux，通过在 IDT 中增设 \lstinline{0x80} 中断门描述符，使其指向正确的中断例程入口就可实现系统调用。
系统调用种类繁多，为提供对不同系统调用的支持，GNU/Linux 在内核中维护一张系统调用表（System Call Table），表中存储各个操作系统内核 API 的入口地址。
用户在发起系统调用前，在 \lstinline{EAX} 中保存系统调用号，即系统调用表中某个内核 API 的索引。
由于线程在进入内核态时会发生栈切换，故在 GNU/Linux 中用户需将系统调用参数按自左向右的顺序依次存放到 \lstinline{EBX}、\lstinline{ECX}、\lstinline{EDX}、\lstinline{ESI}、\lstinline{EDI}、\lstinline{EBP} 寄存器中，
然后通过 \lstinline{int 0x80} 发起系统调用，SimpleOS 也采用该种做法。

在系统调用表 \lstinline{g_SyscallTable} 中，对 \lstinline{VideoTextModePutString} 予以注册。

\begin{minted}[breaklines, breakautoindent=true]{c}
#define __NR_WriteConsole (0) /* 打印字符串 */
void* g_SyscallTable[] = {
    /* 0: WriteConsole */ VideoTextModePutString,
    /* 其他系统调用入口函数 */
}
\end{minted}

用户可在 \lstinline{EAX} 中指定系统调用号 \lstinline{0} 发起系统调用。为方便用户程序使用，可使用宏进行简单封装，将该系统调用对用户命名为 \lstinline{WriteConsole}。

\begin{minted}[breaklines, breakautoindent=true]{c}
#define _syscall1(type, name, type1, param1, type2, param2) \
static inline type name(type1 param1) \
static inline type name(type1 param2) \
{ \
_Static_assert(sizeof(type1) == 4, "Parameter size must be 4 bytes."); \
    __asm \
    {\
        __asm mov eax, __NR_##name \
        __asm mov ebx, param1 \
        __asm int 0x80 \
    } \
}

_syscall1(BOOL, WriteConsole, LPCSTR, lpBuffer, LPDWORD, lpNumberOfCharsWritten)
\end{minted}

上述宏定义展开后，\lstinline{WriteConsole} 将被定义为具有如下形式内联函数。
内联汇编中修改的寄存器将由编译器决定是否需要提前压栈保存。

\begin{minted}[breakautoindent=true, breaklines]{c}
BOOL WriteConsole(LPCSTR lpszBuffer, LPDWORD lpNumberOfCharsWritten)
{
    __asm
    {
        mov eax, __NR_WriteConsole
        mov ebx, lpBuffer
        mov ecx, lpNumberOfCharsWritten
        int 0x80
    }
}
\end{minted}

\lstinline{lpszBuffer} 指明 \lstinline{'\0'} 结尾的 C-Style 字符串存放的位置，字符串本身位于用户空间，线程陷入内核空间后可通过 \lstinline{EBX} 保存的指针找到该字符串。
\lstinline{lpNumberOfCharsWritten} 用于告知用户打印的字符串长度。

\lstinline{WriteConsole} 本质上是对系统调用一层轻薄的函数封装。
用户调用 \lstinline{WriteConsole} 后，执行到 \lstinline{int 0x80} 转而执行中断例程。
线程执行流携带保存在寄存器中的参数陷入内核态，\lstinline{0x80} 中断例程将参数根据 API 的需要存储到内核栈，而后根据 \lstinline{EAX} 的值派发相应的系统调用函数。
系统调用执行完毕返回到 \lstinline{0x80} 中断例程后，由例程负责将发起系统调用的线程恢复到用户态。

\begin{minted}[breakautoindent=true, breaklines]{c}
extern g_SyscallTable
global DispatchSyscall
; 在栈上依次存放 6 个参数
DispatchSyscall: dd SyscallEntrypoint
SyscallEntrypoint:
    push ds
    ; 将寄存器参数压入内核栈
    mov ebx, KERNEL_DATA_SELECTOR ; 用于访问内核数据段
    mov ds, ebx
    call [g_SyscallTable + eax * 4]
    add esp, 6 * 4 ; 跳过栈中的参数
    pop ds
    iret
\end{minted}

由于用户态在发起调用前已经将需要保存的诸寄存器的原值存放于栈中，故中断例程并未将压栈的寄存器值恢复，以此节省系统调用开销，\lstinline{iret} 将执行流恢复到用户态。

\subsubsection{将系统调用封装为库函数}

在前述实现的 \lstinline{WriteConsole} 系统调用，就可以利用该系统调用实现 C 语言标准库中的 \lstinline{printf}（见附录 \ref{PRINTF}）。\lstinline{printf} 本质上是将格式化字符串解析为实际需要打印的字符串，然后将该字符串交付给系统调用 \lstinline{WriteConsole}。

\lstinline{printf} 的逻辑判断部分运行于用户态，只有需要使用非计算型功能，即打印字符串时，才发起系统调用进入内核态。
故为避免频繁发起系统调用导致的额外开销，实现 \lstinline{printf} 时，可将尽可能多的字符存放在用户空间的缓冲区中，等到缓冲区中的字符数量达到一定限制再发起系统调用。
本项目采用了“行缓冲”模式，一次缓冲一行文本。
\lstinline{printf} 是变参函数，参数的实际数量由格式化字符串 \lstinline{lpszFmt} 确定。根据 C 调用约定，参数自右向左入栈。
传参时不足 \lstinline{DWORD} 大小的参数依据类型提升规则全部被扩充为 \lstinline{DWORD} 大小。因此，获取参数时只需要以最后入栈的参数 \lstinline{lpszFmt} 为基准，每次向上取出 \lstinline{DWORD} 大小的数据（\lstinline{QWORD} 类型数据需要取两次）。
